#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
An in-memory and disk cache for pySlip tiles.

Requires a wxPython application to be created before use.
If we can create a bitmap without wxPython, we could remove this dependency.
"""

# Copyright (c) 2010, Ross Wilson (rzzzwilson@gmail.com). All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following conditions
# are met:
#  * Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.


import os
import wx

# if we don't have log.py, don't crash
try:
    import log
    log = log.Log('pyslip.log', log.Log.DEBUG)
except ImportError:
    def log(*args, **kwargs):
        pass


######
# Base class for a tile object - handles access to a source of tiles.
######

class TileCache(object):
    """An object that caches tiles."""

    # path to tile in on-disk cache, %params are (x, y)
    TilePath = os.path.join('%d', '%d.png')

    # maximum number of tiles for each level
    MaxTileLevelHolding = 4000

    def __init__(self, cache_dir, levels):
        """Create the tile cache.

        cache_dir  path to a possibly existing directory cache of tiles
        levels     list of level numbers that will be used
        """

        # prepare tile cache if not already there
        self.cache_dir = cache_dir
        if not os.path.isdir(cache_dir):
            if os.path.isfile(cache_dir):
                msg = ("%s doesn't appear to be a tile cache directory"
                       % cache_dir)
                raise Exception(msg)
            os.makedirs(cache_dir)
        for level in levels:
            level_dir = os.path.join(cache_dir, '%d' % level)
            if not os.path.isdir(level_dir):
                os.makedirs(level_dir)

        # setup the tile caches and LRU lists
        self.cache = {}
        self.lru = {}
        for l in levels:
            self.cache[l] = {}
            self.lru[l] = []

    def GetTile(self, level, x, y):
        """Get bitmap for tile at level,x,y.

        level  level to get tile from
        x      X coord of tile required (tile coordinates)
        y      Y coord of tile required (tile coordinates)

        Return tile from the local cache, return None if not found.
        """

        log('GetTile: %d,%d,%d' % (level, x, y))

        # the tile 'key'
        tile_key = (x, y)

        try:
            # if tile in cache, return it from there
            bitmap = self.cache[level][tile_key]
            self.lru[level].remove((x, y))      # remove, add at recent end
            self.lru[level].insert(0, tile_key)
            log('GetTile: tile found in cache')
        except KeyError:
            log('GetTile: tile NOT in cache')
            # tile *not* in memory cache look in disk cache
            tile_dir = os.path.join(self.cache_dir, '%d' % level)
            tile_path = os.path.join(tile_dir, self.TilePath % (x, y))
            if not os.path.exists(tile_path):
                log('GetTile: tile %s not found!' % tile_path)
                # tile not there, return None
                bitmap = None
            else:
                log('GetTile: found tile on disk')
                # we have the tile file - read into memory, cache & return
                image = wx.Image(tile_path, wx.BITMAP_TYPE_ANY)
                bitmap = image.ConvertToBitmap()
                self.cache[level][tile_key] = bitmap
                self.lru[level].insert(0, tile_key)

                # newly cached tile, check if we must drop old cached tiles
                self._trim_cache(level)

        return bitmap

    def CacheTile(self, image, bitmap, level, x, y):
        """Save tile in the disk and in-memory cache.

        image   data to save in disk cache file
        bitmap  the tile bitmap to save in LRU cache
        level   level of the tile
        x       X coord of tile
        y       Y coord of tile
        """

        # the tile 'key'
        tile_key = (x, y)

        # put bitmap into the in-memory cache
        self.cache[level][tile_key] = bitmap

        # add this tile at "most recent" end of the LRU list
        self.lru[level].insert(0, tile_key)

        # drop relatively unused tiles from in-memory cache
        self._trim_cache(level)

        # write data to the appropriate disk file
        tile_dir = os.path.join(self.cache_dir, '%d' % level)
        tile_path = os.path.join(tile_dir, self.TilePath % (x, y))
        dir_name = os.path.dirname(tile_path)
        try:
            os.makedirs(dir_name)
        except OSError:
            pass
        image.SaveFile(tile_path, wx.BITMAP_TYPE_JPEG)

    def _trim_cache(self, level):
        """Ensure the cache for a level isn't too large."""

        while len(self.lru[level]) > self.MaxTileLevelHolding:
            tile_key = self.lru[level].pop()    # remove old tile from LRU
            try:
                del self.cache[level][tile_key] # and remove from cache
            except KeyError:
                # don't worry about cache/lru mismatches
                pass

    def _get_cache_size(self, level):
        """Return the number of tiles in cache for 'level'"""

        return len(self.cache[level])

################################################################################

if __name__ == '__main__':
    import os
    import unittest
    import shutil

    TilesDir = './tiles_test'
    TileFilename = 'pending_tile.png'

    DefaultAppSize = (512, 512)
    DemoName = 'OSM Tiles Test'
    DemoVersion = '0.1'

    class AppFrame(wx.Frame):

        def __init__(self):
            wx.Frame.__init__(self, None, size=DefaultAppSize,
                              title='%s %s' % (DemoName, DemoVersion))
            self.SetMinSize(DefaultAppSize)
            self.panel = wx.Panel(self, wx.ID_ANY)
            self.panel.SetBackgroundColour(wx.WHITE)
            self.panel.ClearBackground()
            self.Bind(wx.EVT_CLOSE, self.onClose)

            unittest.main()

        def onClose(self, event):
            self.Destroy()

    class TestCache(unittest.TestCase):
        def setUp(self):
            self.tile_image = wx.Image(TileFilename, wx.BITMAP_TYPE_ANY)
            self.tile_bitmap = self.tile_image.ConvertToBitmap()
            os.makedirs(TilesDir)

        def tearDown(self):
            shutil.rmtree(TilesDir)

        def testSimple(self):
            """Cache & retrieve 1 tile from a simple cache."""

            cache = TileCache(TilesDir, [0])
            cache.CacheTile(self.tile_image, self.tile_bitmap, 0, 0, 0)
            tile = cache.GetTile(0, 0, 0)
            self.failUnless(tile == self.tile_bitmap)

        def testBigger(self):
            """Cache & retrieve 1 tile to many levels"""

            levels = [0, 1, 2, 3, 4, 5]
            cache = TileCache(TilesDir, levels)
            for l in levels:
                cache.CacheTile(self.tile_image, self.tile_bitmap, l, l, l)
            for l in levels:
                tile = cache.GetTile(l, l, l)
                self.failUnless(tile == self.tile_bitmap)

        def testLRU(self):
            """Cache & retrieve lots of tiles from many levels.

            Tile comparison actually compares id() values.  These should be
            different only when tile is fetched from disk cache.
            """

            orig_width = self.tile_bitmap.GetWidth()
            orig_height = self.tile_bitmap.GetHeight()
            orig_depth = self.tile_bitmap.GetDepth()
            num_tiles = 10000        # more than max for each level (4096)
            levels = [0, 1, 2, 3]
            cache = TileCache(TilesDir, levels)

            # should create unique images...
            for l in levels:
                for n in range(num_tiles):
                    cache.CacheTile(self.tile_image, self.tile_bitmap, l, n, n)

            # should test for the identical image...
            for l in levels:
                for n in range(num_tiles):
                    tile = cache.GetTile(l, n, n)
                    if (tile.GetWidth() != orig_width or
                        tile.GetHeight() != orig_height or
                        tile.GetDepth() != orig_depth):
                        msg = ('level %d, tile %d: '
                               'retrieved tile != self.tile_bitmap'
                               % (l, n))
                        self.fail(msg)
                    if (tile != self.tile_bitmap and    # compares id() values
                        cache._get_cache_size(l) <
                            TileCache.MaxTileLevelHolding):
                        msg = ('level %d, tile %d: '
                               'retrieved tile != self.tile_bitmap '
                               'and cache not full (%d,%d)'
                               % (l, n, cache._get_cache_size(l),
                                  TileCache.MaxTileLevelHolding))
                        self.fail(msg)

    app = wx.App()
    app_frame = AppFrame()
    app_frame.Show()
    app.MainLoop()

